package com.legsem.jhosync;

import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;

import org.apache.commons.codec.digest.DigestUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.legsem.jhosync.change.Changes;
import com.legsem.jhosync.change.ChangesSet;
import com.legsem.jhosync.change.SourceErrors;
import com.legsem.jhosync.face.SourceAdapter;
import com.legsem.jhosync.face.SourceAdapterException;
import com.legsem.jhosync.model.MasterDoc;
import com.legsem.jhosync.util.KAVUtil;

/**
 * This is a proxy that represents the source from a synching standpoint.
 * 
 */
public class SourceSync {

    final Logger logger = LoggerFactory.getLogger(SourceSync.class);

    private SourceAdapter source;

    private SourceConfig sourceConfig;

    private MasterDoc masterDoc;

    private Context context;

    /**
     * Creates a new instance of the developers implemented source and retrieves
     * the master document.
     * <p/>
     * The Master document is either attached to the user login (the default) or
     * a shared login if the source partition type is application.
     * 
     * @param app_name the application name
     * @param login the user ID
     * @param sourceConfig the source configuration
     * @param context the session and configuration parameters
     * @throws ServerException if source is not configured properly
     */
    public SourceSync(String app_name, String login, SourceConfig sourceConfig,
            Context context) throws ServerException {
        try {
            this.sourceConfig = sourceConfig;
            this.context = context;
            source = sourceConfig.getSourceClass().newInstance();
            String mdLogin = (sourceConfig.isShared()) ? Constants.SHARED_LOGIN
                    : login;
            masterDoc = MasterDoc.find(app_name, sourceConfig.getName(),
                    mdLogin);
            if (masterDoc == null) {
                masterDoc = new MasterDoc(app_name, sourceConfig.getName(),
                        mdLogin);
                masterDoc.create();
            }

        } catch (InstantiationException e) {
            throw new ConfigurationException(e);
        } catch (IllegalAccessException e) {
            throw new ConfigurationException(e);
        } catch (SQLException e) {
            throw new ServerException(e);
        }
    }

    /**
     * A query has been received from the client.
     * <p/>
     * TODO Source can be asynchronous. Need queuing provider.
     * <p/>
     * Notice that we lock the master document record pessimistically. This
     * avoids any concurrent thread from attempting to update the master
     * document while we are processing it.
     * <p/>
     * Errors detected in this process are stored with the master document and
     * used to notify all subsequent queries that something is wrong with the
     * backend. On the next poll, errors might get fixed.
     * <p/>
     * If the query fails, we still need to logoff, which might also fail but we
     * ignore that second error because it is more important to notify client of
     * the primary error cause.
     * 
     * @param queryParams any additional query parameters
     * @return the source errors that were detected or null if everything fine
     * @throws SQLException if storage fails
     */
    public SourceErrors processQuery(Map < String, Object > queryParams)
            throws SQLException {

        logger.debug("Received query request for {}", masterDoc.toString());

        masterDoc = MasterDoc.findForUpdate(masterDoc.getAppName(),
                masterDoc.getSourceName(), masterDoc.getUserLogin());
        SourceErrors errors = new SourceErrors(masterDoc.getErrors());

        if (isNeedRefresh()) {
            logger.debug("Need to refresh master document from source {}",
                    getSourceConfig());
            errors = delegateLogin();
            if (errors == null) {
                delegateMetadata();
                errors = delegateQuery(queryParams);
                if (errors == null) {
                    errors = delegateLogoff();
                } else {
                    delegateLogoff();
                }
            }
            masterDoc.setErrors((errors == null) ? null : errors.encode());
            masterDoc.update();
            logger.debug("Master document updated: {} ", masterDoc);
        }
        return errors;
    }

    /**
     * This processes a batch of updates.
     * <p/>
     * Each individual update is handed over to the developer's customized
     * source. Update might succeed or fail.
     * <p/>
     * Once the source had a chance to process the entire batch, inserts and
     * deletes (but not updates) are applied to the master document. Unsure why
     * this is necessary, since the master will be completely refreshed from the
     * backend on the next poll.
     * <p/>
     * If source errors (related to login/logoff for CUD) are detected, they are
     * stored because client won't know about them until next query.
     * 
     * 
     * @param createRequests the creates requested by the client
     * @param updateRequests the updates requested by the client
     * @param deleteRequests the deletes requested by the client
     * @return changes to apply on client
     * @throws SQLException storage failure
     */
    public ChangesSet processCUD(
            Map < String, Map < String, Object > > createRequests,
            Map < String, Map < String, Object > > updateRequests,
            Map < String, Map < String, Object > > deleteRequests)
            throws SQLException {

        logger.debug("Received cud request for {}", masterDoc.toString());

        ChangesSet changes = delegateCUD(createRequests, updateRequests,
                deleteRequests);
        masterDoc.setMd(changes.apply(masterDoc.getMd()));
        masterDoc.setMdSize(masterDoc.getMdSize() + changes.updateCount());
        masterDoc.setErrors(changes.getSourceErrors().encode());
        masterDoc.update();
        logger.debug("Master document updated: {} ", masterDoc);
        return changes;

    }

    /*
     * ------------------------------------------------------------------------
     * Delegate processing to a developer's provided source
     * ------------------------------------------------------------------------
     */
    /**
     * Allow Source to login to the backend system.
     * 
     * @return the source errors that were detected or null if everything fine
     * @throws SourceAdapterException if login fails
     */
    protected SourceErrors delegateLogin() {
        try {
            source.login(getContext());
            return null;
        } catch (SourceAdapterException e) {
            SourceErrors errors = new SourceErrors();
            handleSourceException("login", e, errors);
            return errors;
        }
    }

    /**
     * Signoff from the backend system.
     * 
     * @return the source errors that were detected or null if everything fine
     * 
     */
    protected SourceErrors delegateLogoff() {
        try {
            source.logoff();
            return null;
        } catch (SourceAdapterException e) {
            SourceErrors errors = new SourceErrors();
            handleSourceException("logoff", e, errors);
            return errors;
        }
    }

    /**
     * Let the developer source perform the query then update the master copy of
     * the document with the data received.
     * <p/>
     * The data received, which is a map of maps, is encoded to fit in a CLOB
     * and ease diffing.
     * <p/>
     * While updating the document we also set the next scheduled refresh time.
     * 
     * @return the source errors that were detected or null if everything fine
     * @param queryParams the query parameters
     */
    protected SourceErrors delegateQuery(Map < String, Object > queryParams) {
        try {
            Map < String, Map < String, Object > > results = source
                    .query(queryParams);
            logger.debug("Source returned results: {}", results.toString());

            String encoded = KAVUtil.encode(results);

            masterDoc.setMd(encoded);
            masterDoc.setMdSize(results.size());
            masterDoc.setRefreshTime(new Timestamp(System.currentTimeMillis()
                    + (getSourceConfig().getPollInterval() * 1000)));
            return null;
        } catch (SourceAdapterException e) {
            SourceErrors errors = new SourceErrors();
            handleSourceException("query", e, errors);
            return errors;
        }
    }

    /**
     * Call the developer's source for each update request and evaluate the
     * changes that new to be applied to master document.
     * <p/>
     * All errors are reported as part of the change set returned.
     * 
     * @param createRequests the creates requested by the client
     * @param updateRequests the updates requested by the client
     * @param deleteRequests the deletes requested by the client
     * @return the changes to be applied on client and master document
     */
    protected ChangesSet delegateCUD(
            Map < String, Map < String, Object > > createRequests,
            Map < String, Map < String, Object > > updateRequests,
            Map < String, Map < String, Object > > deleteRequests) {

        ChangesSet changes = new ChangesSet();

        SourceErrors errors = delegateLogin();
        if (errors != null) {
            changes.setSourceErrors(errors);
            return changes;
        }

        if (createRequests != null) {
            for (Entry < String, Map < String, Object > > entry : createRequests
                    .entrySet()) {
                delegateCreate(entry.getKey(), entry.getValue(), changes);
            }
        }
        if (updateRequests != null) {
            for (Entry < String, Map < String, Object > > entry : updateRequests
                    .entrySet()) {
                delegateUpdate(entry.getKey(), entry.getValue(), changes);
            }
        }
        if (deleteRequests != null) {
            for (Entry < String, Map < String, Object > > entry : deleteRequests
                    .entrySet()) {
                delegateDelete(entry.getKey(), entry.getValue(), changes);
            }
        }

        errors = delegateLogoff();
        if (errors != null) {
            changes.setSourceErrors(errors);
        }

        return changes;
    }

    /**
     * The developers source returns a new identifier for a newly create entity.
     * <p/>
     * This new identifier is sent back to the client by 'linking' it to the
     * temporary identifier assigned by the client.
     * <p/>
     * Observe that create_deletes are only updated if the source returns null
     * but does not raise an exception. TODO what is this used for?
     * 
     * @param tempId the temporary identifier assigned by client
     * @param attributes the attributes names and values of the new entity
     * @param changes the changes to be applied on client and master document
     */
    protected void delegateCreate(String tempId,
            Map < String, Object > attributes, ChangesSet changes) {

        try {
            String finalId = source.create(attributes);
            if (finalId != null) {
                Map < String, Object > entry = new HashMap < String, Object >();
                entry.put("l", finalId);
                changes.getCreateLinks().put(tempId, entry);
                changes.getInserts().put(finalId, attributes);
            } else {
                changes.getCreateDeletes().put(tempId, attributes);
            }
        } catch (SourceAdapterException e) {
            handleSourceException("create", e, tempId, attributes,
                    changes.getCreateErrors());
        }

    }

    /**
     * The primary key is added to the attributes before the source developer's
     * code is invoked.
     * <p/>
     * Surprisingly, update changes are not not propagated back for CD and MD
     * updates. This means the MD will not reflect these updates until the next
     * poll. This is weird, because inserts and deletes are reflected
     * immediately so this is somehow inconsistent.
     * 
     * @param id the entity to be updated primary key
     * @param attributes the attributes that changed (and only these)
     * @param changes the changes to be applied on client and master document
     */
    protected void delegateUpdate(String id, Map < String, Object > attributes,
            ChangesSet changes) {
        try {
            attributes.put("id", id);
            source.update(attributes);
        } catch (SourceAdapterException e) {
            handleSourceException("update", e, id, attributes,
                    changes.getUpdateErrors());
        }
    }

    /**
     * The primary key is added to the attributes before the source developer's
     * code is invoked.
     * <p/>
     * Upon success the deletes map is updated to help later update the master
     * and client documents.
     * 
     * @param id the entity to be deleted primary key
     * @param attributes the entity attributes (all of them)
     * @param changes the changes to be applied on client and master document
     */
    protected void delegateDelete(String id, Map < String, Object > attributes,
            ChangesSet changes) {
        try {
            attributes.put("id", id);
            source.delete(attributes);
            changes.getDeletes().put(id, attributes);
        } catch (SourceAdapterException e) {
            handleSourceException("delete", e, id, attributes,
                    changes.getDeleteErrors());
        }
    }

    /**
     * Request metadata from the backend system. If some is returned, update the
     * master document to reflect metadata and associated digest.
     * 
     */
    protected void delegateMetadata() {
        String metadata = source.metadata();
        if (metadata != null) {
            masterDoc.setMetadata(metadata);
            masterDoc.setMetadataSha1(DigestUtils.shaHex(metadata));
        }
    }

    /*
     * ------------------------------------------------------------------------
     * Protected helper methods
     * ------------------------------------------------------------------------
     */
    /**
     * If the poll interval is zero it means refresh is always needed (why use
     * rhosync in this case?).
     * <p/>
     * If the source poll interval is negative, it means never refresh (why use
     * rhosync in this case?).
     * <p/>
     * Otherwise if scheduled refresh time is passed, then return true.
     * 
     * @return true if master document needs to be refreshed
     * @throws SQLException storage failure
     */
    protected boolean isNeedRefresh() throws SQLException {
        if (getSourceConfig().getPollInterval() == 0) {
            return true;
        }
        if (getSourceConfig().getPollInterval() < 0) {
            return false;
        }
        return masterDoc.isNeedRefresh();
    }

    /**
     * A generic way of reporting exceptions raised from the source for Query
     * operations.
     * 
     * @param operation the operation either create, update or delete
     * @param e the exception
     * @param errors map used to store errors for later delivery to client
     */
    protected void handleSourceException(String operation,
            SourceAdapterException e, SourceErrors errors) {

        logger.warn("Source failed to " + operation, e);

        Map < String, Object > error = new TreeMap < String, Object >();
        error.put("message", e.getMessage());
        errors.put(operation + "-error", error);

    }

    /**
     * A generic way of reporting exceptions raised from the source for CUD
     * operations.
     * 
     * @param operation the operation either create, update or delete
     * @param e the exception
     * @param id the primary key of the entity involved
     * @param attributes the attributes of the entity involved
     * @param errors map used to store errors for later delivery to client
     */
    protected void handleSourceException(String operation,
            SourceAdapterException e, String id,
            Map < String, Object > attributes, Changes errors) {

        logger.warn("Source failed to " + operation + " entity " + id, e);

        errors.put(id, attributes);
        Map < String, Object > error = new HashMap < String, Object >();
        error.put("message", e.getMessage());
        errors.put(id + "-error", error);

    }

    /*
     * ------------------------------------------------------------------------
     * Properties
     * ------------------------------------------------------------------------
     */
    public SourceAdapter getSource() {
        return source;
    }

    public MasterDoc getMasterDoc() {
        return masterDoc;
    }

    public SourceConfig getSourceConfig() {
        return sourceConfig;
    }

    public Context getContext() {
        return context;
    }

}
